#!/usr/bin/env python

__version__ = "0.3"
__program__ = "Lanshare"
__title__ = "{0} v{1}".format(__program__, __version__)
__author__ = "Matt Joiner <anacrolix@gmail.com>"

import asyncore
import ConfigParser as configparser
import contextlib
import errno
import os.path
import pdb
import shlex
import socket
import stat
import sys
import tarfile
import time
import traceback
try:
    import grp
    import pwd
except ImportError:
    grp = pwd = None
from pyftpdlib import ftpserver

def get_user_by_uid(uid):
    """Return the username associated with user id.
    If this can't be determined return raw uid instead.
    On Windows just return "owner".
    """
    if pwd is not None:
        try:
            return pwd.getpwuid(uid).pw_name
        except KeyError:
            return uid
    else:
        return "owner"

def get_group_by_gid(gid):
    """Return the groupname associated with group id.
    If this can't be determined return raw gid instead.
    On Windows just return "group".
    """
    if grp is not None:
        try:
            return grp.getgrgid(gid).gr_name
        except KeyError:
            return gid
    else:
        return "group"

def proftpd_ls_line(real_path, shown_name, use_gmt_times):
    import os, time, stat
    if use_gmt_times:
        timefunc = time.gmtime
    else:
        timefunc = time.localtime
    try:
        st = os.lstat(real_path)
    except OSError as exc:
        print exc
        if exc.errno == errno.ENOENT:
            return None
        raise
    perms = tarfile.filemode(st.st_mode)  # permissions
    nlinks = st.st_nlink  # number of links to inode
    if not nlinks:  # non-posix system, let's use a bogus value
        nlinks = 1
    size = st.st_size  # file size
    uname = get_user_by_uid(st.st_uid)
    gname = get_group_by_gid(st.st_gid)
    try:
        mtime = time.strftime("%b %d %H:%M", timefunc(st.st_mtime))
    except ValueError:
        # It could be raised if last mtime happens to be too
        # old (prior to year 1900) in which case we return
        # the current time as last mtime.
        mtime = time.strftime("%b %d %H:%M", timefunc())
    # if the file is a symlink, resolve it, e.g. "symlink -> realfile"
    if stat.S_ISLNK(st.st_mode) and hasattr(os, 'readlink'):
        target = os.readlink(real_path)
        # TODO if the target is abs, we could resolve it to a permitted share
        if not os.path.isabs(target):
            shown_name = shown_name + " -> " + target

    # formatting is matched with proftpd ls output
    return "%s %3s %-8s %-8s %8s %s %s\r\n" % (perms, nlinks, uname, gname,
                                               size, mtime, shown_name)

def format_mlsx(template_path, shown_name, perms, facts, use_gmt_times):
    """See ftpserver.AbstractedFS.format_mlsx"""
    import stat, time
    if use_gmt_times:
        timefunc = time.gmtime
    else:
        timefunc = time.localtime
    permdir = ''.join([x for x in perms if x not in 'arw'])
    permfile = ''.join([x for x in perms if x not in 'celmp'])
    if ('w' in perms) or ('a' in perms) or ('f' in perms):
        permdir += 'c'
    if 'd' in perms:
        permdir += 'p'
    type = size = perm = modify = create = unique = mode = uid = gid = ""
    # to properly implement 'unique' fact (RFC-3659, chapter
    # 7.5.2) we are supposed to follow symlinks, hence use
    # os.stat() instead of os.lstat()
    try:
        st = os.stat(template_path)
    except OSError as exc:
        traceback.print_exc()
        if exc.errno == errno.ENOENT:
            return None
        raise
    # type + perm
    if stat.S_ISDIR(st.st_mode):
        if 'type' in facts:
            if shown_name == '.':
                type = 'type=cdir;'
            elif shown_name == '..':
                type = 'type=pdir;'
            else:
                type = 'type=dir;'
        if 'perm' in facts:
            perm = 'perm=%s;' % permdir
    else:
        if 'type' in facts:
            type = 'type=file;'
        if 'perm' in facts:
            perm = 'perm=%s;' % permfile
    if 'size' in facts:
        size = 'size=%s;' %st.st_size  # file size
    # last modification time
    if 'modify' in facts:
        try:
            modify = 'modify=%s;' % time.strftime("%Y%m%d%H%M%S",
                                    timefunc(st.st_mtime))
        # it could be raised if last mtime happens to be too old
        # (prior to year 1900)
        except ValueError:
            modify = ""
    if 'create' in facts:
        # on Windows we can provide also the creation time
        try:
            create = 'create=%s;' % time.strftime("%Y%m%d%H%M%S",
                                    timefunc(st.st_ctime))
        except ValueError:
            create = ""
    # UNIX only
    if 'unix.mode' in facts:
        mode = 'unix.mode=%s;' % oct(st.st_mode & 0777)
    if 'unix.uid' in facts:
        uid = 'unix.uid=%s;' % st.st_uid
    if 'unix.gid' in facts:
        gid = 'unix.gid=%s;' % st.st_gid

    # We provide unique fact (see RFC-3659, chapter 7.5.2) on
    # posix platforms only; we get it by mixing st_dev and
    # st_ino values which should be enough for granting an
    # uniqueness for the file listed.
    # The same approach is used by pure-ftpd.
    # Implementors who want to provide unique fact on other
    # platforms should use some platform-specific method (e.g.
    # on Windows NTFS filesystems MTF records could be used).
    if 'unique' in facts:
        unique = "unique=%x%x;" % (st.st_dev, st.st_ino)

    return "%s%s%s%s%s%s%s%s%s %s\r\n" % (type, size, perm, modify,
                                          create, mode, uid, gid, unique,
                                          shown_name)

def has_perm(share, username, perm, grab_config):
    with grab_config as config:
        if username not in config.users:
            print "Unknown user %r" % username
            return False
        if      share not in config.users[username].granted \
                and not config.shares[share].public:
            return False
        return perm in config.shares[share].perms

class ShareRoot(object):

    def __init__(self, username, config):
        self.__username = username
        self.__config = config

    def validpath(self):
        return True
    def fs2ftp(self):
        return "/"
    def get_list_dir(self, use_gmt_times):
        with self.__config as config:
            for share in config.shares:
                 # TODO e or l?!
                if not has_perm(share, self.__username, "l", self.__config):
                    continue
                path = config.share(share).path
                if path is None:
                    continue
                line = proftpd_ls_line(path, share, use_gmt_times)
                if line is None:
                    continue
                yield line

    def has_perm(self, username, perm):
        assert username == self.__username
        with self.__config as config:
            return username in config.users and perm in "el"

    def realpath(self):
        return self
    def isfile(self):
        return False
    def chdir(self):
        pass
    def isdir(self):
        return True

    def listdir(self):
        with self.__config as config:
            listing = []
            for share in config.shares:
                if has_perm(share, self.__username, "l", self.__config):
                    listing.append(share)

    def format_mlsx(self, listing, perms, facts, use_gmt_times):
        with self.__config as config:
            for entry in listing:
                line = format_mlsx(config.shares[entry].path, entry, perms, facts, use_gmt_times)
                if line is not None:
                    yield line

class RealPath(object):

    def __init__(self, share, rel, config):
        self._share = share
        self._rel = rel
        self._config = config
        with self._config as config:
            if share in config.shares:
                self._path = os.path.join(config.share(share).path, rel)
            else:
                self._path = None

    def validpath(self):
        with self._config as config:
            return self._share in config.shares # and self._path is not None?
    def has_perm(self, username, perm):
        return has_perm(self._share, username, perm, self._config)
    def fs2ftp(self):
        return "/" + self._share + "/" + self._rel
    def chdir(self):
        os.chdir(self._path)
    def isdir(self):
        return os.path.isdir(self._path)
    def isfile(self):
        return os.path.isfile(self._path)
    def get_list_dir(self, use_gmt_times):
        if self.isdir():
            names = sorted(os.listdir(self._path))
            dir = self._path
        else:
            names = [os.path.basename(self._path)]
            dir = os.path.dirname(self._path)
        for n in names:
            line = proftpd_ls_line(os.path.join(dir, n), n, use_gmt_times)
            if not line:
                continue
            yield line
    def realpath(self):
        return self
    def listdir(self):
        return os.listdir(self._path)
    def format_mlsx(self, listing, perms, facts, use_gmt_times):
        for entry in listing:
            line = format_mlsx(os.path.join(self._path, entry), entry, perms, facts, use_gmt_times)
            if line is not None:
                yield line

    def getsize(self):
        return os.path.getsize(self._path)

    def open(self, mode):
        return open(self._path, mode)

    def mkdir(self):
        return os.mkdir(self._path)

    def remove(self):
        return os.remove(self._path)

    def rmdir(self):
        return os.rmdir(self._path)

    #def __str__(self):
        #return self._path

    def __repr__(self):
        return "RealPath(share=%r, path=%r)" % (self._share, self._path)

    def lexists(self):
        return os.path.lexists(self._path)

    def rename(self, dest):
        assert self._share == dest._share
        os.rename(self._path, dest._path)

    def getmtime(self):
        return os.path.getmtime(self._path)

class LanshareAuthorizer(object):

    def __init__(self, config):
        self.config = config

    def validate_authentication(self, username, password):
        with self.config as config:
            if username not in config.users:
                return False
            correct = config.users[username].password
            #pdb.set_trace()
            if correct is None:
                return True
            return password == correct
    def get_msg_login(self, username):
        return "Welcome %s" % username
    def get_home_dir(self, username):
        return None
    def has_perm(self, username, perm, path):
        return path.has_perm(username, perm)
    def impersonate_user(self, username, password):
        pass
    def terminate_impersonation(self, username):
        pass
    def get_perms(self, username):
        return ""
    def get_msg_quit(self, username):
        return "Goodbye %s." % username

class LanshareAbstractedFS(object):
    __basefs = ftpserver.AbstractedFS
    def __init__(self, root, cmd_channel):
        assert root is None, root
        self.cmd_channel = cmd_channel
        self.__cwd = "/"
    def ftpnorm(self, ftppath):
        if os.path.isabs(ftppath):
            p = os.path.normpath(ftppath)
        else:
            p = os.path.normpath(os.path.join(self.cwd, ftppath))
        # normalize string in a standard web-path notation having '/'
        # as separator.
        p = p.replace("\\", "/")
        # os.path.normpath supports UNC paths (e.g. "//a/b/c") but we
        # don't need them.  In case we get an UNC path we collapse
        # redundant separators appearing at the beginning of the string
        while p[:2] == '//':
            p = p[1:]
        # Anti path traversal: don't trust user input, in the event
        # that self.cwd is not absolute, return "/" as a safety measure.
        # This is for extra protection, maybe not really necessary.
        if not os.path.isabs(p):
            p = "/"
        return p
    @property
    def username(self):
        return self.cmd_channel.username
    def ftp2fs(self, ftppath):
        names = self.ftpnorm(ftppath).split("/")
        assert not names[0], names
        if len(names) == 2 and not names[1]:
            return ShareRoot(
                self.cmd_channel.username,
                self.cmd_channel.lanshare_config)
        else:
            #set_trace()
            return RealPath(names[1], "/".join(names[2:]), self.cmd_channel.lanshare_config)
    def get_list_dir(self, path):
        return path.get_list_dir(self.cmd_channel.use_gmt_times)
    def chdir(self, path):
        origdir = os.getcwd()
        path.chdir()
        os.chdir(origdir)
        self.__cwd = path.fs2ftp()
    def format_mlsx(self, path, listing, perms, facts):
        return path.format_mlsx(listing, perms, facts, self.cmd_channel.use_gmt_times)
    def __getattr__(self, name):
        if name in ["isfile", "islink", "isdir", "getsize", "getmtime", "realpath", "lexists", "validpath", "fs2ftp", "get_list_dir", "chdir", "listdir", "open", "mkdir", "remove", "rmdir", "rename"]:
            def path_wrapper(path, *args, **kwargs):
                return getattr(path, name)(*args, **kwargs)
            return path_wrapper
        raise AttributeError("%r missing attribute %r" % (self, name))

    def realpath(self, path):
        if path is None:
            return None
        return path.realpath()

    @property
    def cwd(self):
        return self.__cwd

    @property
    def root(self):
        return None

class ConfigSection(object):

    def __init__(self, section, parser):
        self.__section = section
        self.__parser = parser

    def __iter__(self):
        return iter(self.__parser.options(self.__section))

    def __getitem__(self, name):
        return self.__parser.get(self.__section, name)

    def __get_wrapper(funcname, default):
        def _(self, option):
            try:
                return getattr(self.__parser, funcname)(self.__section, option)
            except configparser.NoOptionError:
                return default
        return _

    _get = __get_wrapper("get", None)
    _getboolean = __get_wrapper("getboolean", False)
    #def _get(self, option):
        #try:
            #return self.__parser.get(self.__section, option)
        #except configparser.NoOptionError:
            #return None

    def _set(self, option, value):
        self.__parser.set(self.__section, option, value)

class UserSection(ConfigSection):

    @property
    def password(self):
        return self._get("password") or None

    @password.setter
    def password(self, value):
        return self._set("password", value)

    @property
    def granted(self):
        return shlex.split(self._get("granted") or "")

    @granted.setter
    def granted(self, value):
        return self._set("granted", ' '.join(('"' + x + '"' for x in value)))

class ShareSection(ConfigSection):

    @property
    def path(self):
        return self._get("path") or None

    @path.setter
    def path(self, value):
        self._set("path", value)

    @property
    def perms(self):
        return "".join(set(self._get("perms") or "") | set("elr"))

    @perms.setter
    def perms(self, value):
        return self._set("perms", value)

    @property
    def public(self):
        return self._getboolean("public")

    @public.setter
    def public(self, value):
        return self._set("public", {True: "yes", False: "no"}[value])

class SectionListConfig(object):
    from ConfigParser import RawConfigParser as Parser

    def __init__(self):
        self._parser = self.Parser()
        self._parser.readfp(open(self._write_path))

    def __iter__(self):
        return iter(self._parser.sections())

    def __getitem__(self, name):
        if self._parser.has_section(name):
            return self._section_class(name, self._parser)
        else:
            raise IndexError("No section %r" % name)

    def __setitem__(self, section, options):
        self._parser.add_section(section)
        for opt in options:
            self._parser.set(section, opt, options[opt])

    def __delitem__(self, section):
        self._parser.remove_section(section)

    def save(self):
        self._parser.write(open(self._write_path, "w"))

    def setdefault(self, section):
        self[section] = {}

class SharesConfig(SectionListConfig):
    _section_class = ShareSection
    _write_path = "shares.conf"

class UsersConfig(SectionListConfig):
    _section_class = UserSection
    _write_path = "users.conf"

class ServerConfig(object):

    def __init__(self):
        self.users = UsersConfig()
        self.shares = SharesConfig()

    def save(self):
        self.users.save()
        self.shares.save()

    def share(self, share):
        return self.shares[share]

    def users(self, username):
        return self.users[username]

class LanshareAnnouncer(asyncore.dispatcher):

    def __init__(self, address):
        asyncore.dispatcher.__init__(self)
        self.create_socket(socket.AF_INET, socket.SOCK_DGRAM)
        #self.bind(('', address[1]))
        self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, True)
        self.__last_announce = None
        self.__lanshare_server_address = address

    def writable(self):
        return  self.__last_announce is None \
                or time.time() >= self.__last_announce + 1
    ignore_log_types = frozenset()

    def handle_write(self):
        self.sendto(
            repr((socket.getfqdn(), self.__lanshare_server_address[1], "ftp")),
            ('<broadcast>', 2121))
        self.__last_announce = time.time()

    def handle_connect(self):
        pass

class ServerConfigWrapper(object):

    def __enter__(self):
        return ServerConfig()

    def __exit__(self, *args):
        return False

class LanshareFTPHandler(ftpserver.FTPHandler):

    @property
    def lanshare_config(self):
        return self.server.lanshare_config

class Lanshare(object):

    __slots__ = "config", "address", "server"

    def __init__(self):
        handler = LanshareFTPHandler
        self.config = ServerConfigWrapper()
        #global config
        #config = self.config
        handler.authorizer = LanshareAuthorizer(self.config)
        handler.abstracted_fs = LanshareAbstractedFS
        #handler.lanshare_config = config
        self.address = '0.0.0.0', 2121
        self.server = ftpserver.FTPServer(self.address, handler)
        self.server.lanshare_config = self.config
        announce = LanshareAnnouncer(self.address)

    def __call__(self):
        self.server.serve_forever()

    def read_config(self):
        return ServerConfig()

    #@contextlib.contextmanager
    #def config(self):
        #yield ServerConfig()

    #@contextlib.contextmanager
    #def grab_config(self):
        #yield ServerConfig()

def main():
    import logging
    logging.basicConfig(level=logging.DEBUG, stream=sys.stderr)
    lanshare = Lanshare()
    lanshare()

if __name__ == "__main__":
    main()
